﻿/*

Copyright 2010 YiqYaq LLC
Author: Dale Low (gumbypp@yahoo.com)
 
Licensed under the Apache License, Version 2.0 (the "License"); 
you may not use this file except in compliance with the License. 
You may obtain a copy of the License at 
 
    http://www.apache.org/licenses/LICENSE-2.0 
    
Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS, 
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
See the License for the specific language governing permissions and 
limitations under the License. 

The above license does not apply to the RadioWeave logo and name artwork,
examples of which are only included in this project for illustration
purposes.

*/

import JSON;
import Server;
import Utils;

/**
 * \brief Main logic for the radio player.  Uses \ref Server to talk with the RadioWeave server.
 *
 * \ingroup chumby
 */
class Controller
{
    private var kVersion:String = "1.5";
    
    private var kMaxIterationCount:Number = 65535;
    private var kMaxHistory:Number = 16;
    private var kCheckSoundInterval:Number = 5000;
    private var kNetActionDelay:Number = 5000;
    private var kNextPlaylistNoTracksDelay:Number = 60000;
    private var kNextPlaylistSomeTracksDelay:Number = 10000;    
    
    private var fUser:String;
    private var fPwd:String;  
    private var fAnonBeLike:Boolean;

    private var fRequestPlay:Boolean;
    private var fIsPlaying:Boolean;
    private var fSoundLoadCount:Number;
    private var fSoundUseCount:Number;

    private var fMainImage:Object;
    private var fChannelImage:Object;
    private var fSelectImageName:String;
    private var fSelectChannelImageName:String;
    private var fSelectTraq:String;
    private var fSelectTitle:TextField;
    private var fSelectDesc:TextField;
    private var fSelectOwner:TextField;
    private var fSelectChannel:TextField;
    private var fSelectChannelDesc:TextField;
    private var fLoveOpinion:Object;
    private var fHateOpinion:Object;
    private var fPrevBtn:Object;
    private var fNextBtn:Object;
    
    private var fStatusMsg:TextField;
    private var fNowPlayingCallback:Function;
    private var fShowDialogCallback:Function;
    
    private var fServer:Server;
    private var fTraqInfo:Array;
    private var fTraqIndex:Number;
    private var fTraqsRemoved:Number = 0;
    private var fDescTimerID:Number = 0;
    private var fChannelNameTimerID:Number = 0;
    private var fPlaylistTimerID:Number = 0;
    private var fPlaylistErrorCount:Number = 0;    
    private var fLoginTimerID:Number = 0;
        
    private var fStartPlayTimer:Number = 0;        
    private var fSoundTimerIteration:Number = 0;
    private var fSoundTimerID:Number = 0;
    private var fSoundTimerCount:Number = 0;
    private var fSoundLastBytes:Number = 0;
    private var fSoundTimerNoChange:Number = 0;
    private var fLastReportedCurrentTraq:String = null;
    
    private var fMainImageLoader:MovieClipLoader;
    private var fMainImageListener:Object;
    private var fChannelImageLoader:MovieClipLoader;
    private var fChannelImageListener:Object;
    private var fSound:Sound;

    /**
        \brief Private logging function for this module.
        \param msg message to log
      */
    private function log(msg)
    {
        Utils.log("[C] " + msg);
    }

    /**
        \brief Private logging function for this module.
        \param msg warning message to log
      */
    private function logWarn(msg)
    {
        Utils.log("**C " + msg);
    }

    /**
        \brief Update the status message with the track position and total number of tracks.
      */
    private function updateStatusMsg():Void
    {
        fStatusMsg.text = String(fTraqsRemoved) + "+" + String(fTraqIndex+1) + "/" + String(fTraqInfo.length);
    }
    
    /**
        \brief Load a new track's image, its meta-data, and optionally start playing it.
        \param traq track object
      */
    private function loadTraq(traq:Object):Void
    {
        log("loadTraq: loading traq " + traq["traqID"]);
                
        // images - only load if they changed
        if (fSelectImageName != traq["img"])
        {
            fMainImageLoader.loadClip(fServer.imageURLRequest(traq["img"]), fMainImage);
            fSelectImageName = traq["img"];
        }

        if (fSelectChannelImageName != traq["channelImg"])
        {
            fChannelImageLoader.loadClip(fServer.imageURLRequest(traq["channelImg"]), fChannelImage);
            fSelectChannelImageName = traq["channelImg"];
        }

        // meta-data
        fSelectTitle.text = traq["title"];
        if (fDescTimerID)
            clearInterval(fDescTimerID);
        fDescTimerID = Utils.scrollTextField(fSelectDesc, traq["description"] ? traq["description"] : "", fSelectDesc._width);
        fSelectOwner.text = "Contributor: " + traq["name"];
        
        if (fChannelNameTimerID)
            clearInterval(fChannelNameTimerID);
        fChannelNameTimerID = Utils.scrollTextField(fSelectChannel, traq["channelName"] ? traq["channelName"] : "", fSelectChannel._width);
        fSelectChannelDesc.text = traq["channelDesc"] ? traq["channelDesc"] : "";
    
        // opinion
        if (traq.hasOwnProperty("opinion"))
        {
            fLoveOpinion._visible = traq["opinion"] ? true : false;
            fHateOpinion._visible = traq["opinion"] ? false : true;
        }
        else
        {
            fLoveOpinion._visible = false;
            fHateOpinion._visible = false;
        }
    
        // audio
        if (fRequestPlay)
        {
            fSelectTraq = traq["traqID"];

            fStartPlayTimer = setInterval(this, "playCurrentTraq", 
                fIsPlaying ? 1000 /* give previous traq 1 second to finish */ : 0);
        }
        else
        {
            // stop all sounds now if we're not gonna play the new track
            fSound.stop();
            cancelSoundTimer();
            fIsPlaying = false;
        }
        
        updateStatusMsg();
    }

    /**
        \brief Start playing the current traq
      */      
    function playCurrentTraq():Void
    {
        // timer was cancelled
        if (!fStartPlayTimer)
        {
            logWarn("playCurrentTraq: cancelled");
            return;
        }            
            
        clearInterval(fStartPlayTimer);
        fStartPlayTimer = 0;

        // really make sure that nothing else is playing        
        stopAllSounds();
    
        // hack - workaround for sounds stops playing issue:
        // http://forum.chumby.com/viewtopic.php?pid=24081
        // this seems to cause intermittent hangs, so we do it
        // infrequently - maybe once per hour?
        if (++fSoundUseCount >= 60)
        {
            var rc = delete fSound.onLoad;
            if (!rc)
                logWarn("playCurrentTraq: failed to delete fSound.onLoad");

            rc = delete fSound.onSoundComplete;
            if (!rc)
                logWarn("playCurrentTraq: failed to delete fSound.onSoundComplete");

            rc = delete fSound;
            if (!rc)
                logWarn("playCurrentTraq: failed to delete fSound");
        
            logWarn("playCurrentTraq: reinitializing sound");
            initSound();
            fSoundUseCount = 0;
            fSoundLoadCount = 0;
        }

        // note: fSound.position is invalid until the onLoad event is returned
        ++fSoundLoadCount;
        
        log("playCurrentTraq: playing traq " + fSelectTraq + ", sound load count: " + fSoundLoadCount);
        fSound.loadSound(fServer.mp3URLRequest(fSelectTraq), true);

        if (++fSoundTimerIteration >= kMaxIterationCount)
            fSoundTimerIteration = 0;
        fSoundTimerCount = 0;
        fSoundLastBytes = -1;
        fSoundTimerNoChange = 0;
        fSoundTimerID = setInterval(this, "checkSound", kCheckSoundInterval, fSoundTimerIteration);
                    
        // sound usually starts playing before onLoad(true) is called
        fIsPlaying = true;
    }

    /**
        \brief Cancel the sound timer.
      */
    function cancelSoundTimer():Void
    {
        if (fSoundTimerID)
        {
            log("cancelSoundTimer");
            clearInterval(fSoundTimerID);
            fSoundTimerID = 0;
        }
    }
    
    /**
        \brief Check the status of a loading sound file.
      */
    function checkSound(iteration):Void
    {
        if (!fSoundTimerID || (fSoundTimerIteration != iteration))
        {
            logWarn("checkSound: cancelled timer or discarding old event (got " + iteration + " vs. expected " + fSoundTimerIteration);
            return;
        }
            
        var bytesLoaded:Number = fSound.getBytesLoaded();
        var bytesTotal:Number = fSound.getBytesTotal();
                
        log("CS (" + fSoundTimerCount++ + ":" + iteration + "==" + fSoundTimerIteration + "): " + bytesLoaded + "/" + bytesTotal);                
                
        if (bytesLoaded && (bytesLoaded >= bytesTotal))
        {
            log("checkSound: done");
            cancelSoundTimer();
            return;
        }    
        
        fSoundTimerNoChange = (bytesLoaded == fSoundLastBytes) ? (fSoundTimerNoChange + 1) : 0;
        fSoundLastBytes = bytesLoaded;
        
        if (fSoundTimerNoChange >= 6)
        {
            // we've waited 6*5=30 seconds with no change (no bytes at all or frozen)
            logWarn("checkSound: giving up");
            
            // stop, please!
            fSound.stop();
            stopAllSounds();
            cancelSoundTimer();                        
            
            fSelectTraq = null;
            fIsPlaying = false;
            nextTraq(true);
        }
    }

    /**
        \brief Request a new playlist from the server.
      */
    function retryPlaylist():Void
    {
        log("retryPlaylist: retrying playlist");
        if (fPlaylistTimerID)
        {
            clearInterval(fPlaylistTimerID);
            fPlaylistTimerID = 0;
        }

        fServer.fetchMoreTraqs();
    }

    /**
        \brief Callback indicating that new tracks may be available.
               Add the track(s) to our list and load the first one (if applicable) or
               set the playlist timer if none are available.
        \param context 'this' object 
        \param data array of track objects
      */
    private function traqsAvailable(context:Object, data:Object):Void
    {
        // todo - show alerts somewhere
        context.fPlaylistErrorCount = 0;
        
        if (context.fPlaylistTimerID)
        {
            clearInterval(context.fPlaylistTimerID);
            context.fPlaylistTimerID = 0;
        }
                        
        if (!data.length || (data.length == 1))
        {
            // get more stuff soon (wait longer if we got NO tracks)
            context.fPlaylistTimerID = setInterval(context, "retryPlaylist", 
                !data.length ? context.kNextPlaylistNoTracksDelay : context.kNextPlaylistSomeTracksDelay);

            if (!data.length)
            {
                context.logWarn("traqsAvailable: no traqs available.");
                return;
            }
        }

        var firstTraq:Boolean = !context.fTraqInfo.length;
        
        for (var i:Number=0; i<data.length; i++)
        {
            context.fTraqInfo.push(data[i]);
            if (context.fTraqInfo.length > context.kMaxHistory)
            {
                context.fTraqInfo.shift();                
                --context.fTraqIndex;
                ++context.fTraqsRemoved;
            }
        }
    
        context.log("traqsAvailable: new=" + data.length + ", total=" + context.fTraqInfo.length);
        context.updateStatusMsg();
        context.fNextBtn._visible = (context.fTraqInfo.length > 1);
        
        if (firstTraq)
        {
            // start playing ASAP
            context.fRequestPlay = true;
            context.loadTraq(context.fTraqInfo[0]);
            
            // notify server that we're playing
            context.fNowPlayingCallback();
        }
        else if (context.fRequestPlay && !context.fIsPlaying)
        {
            context.logWarn("traqsAvailable: supposed to be playing, but we had run out of traqs - moving to next traq");
            context.nextTraq(true);        
        }
    }

    /**
        \brief Callback indicating that a server error has occurred.
               Sends a new request or displays an appropriate error message to the user.
        \param context 'this' object 
        \param errorInfo object with 'state', 'type' and 'msg' properties
        \return true to continue with the next queued request (if any), 
                false to send an interposing request.
     */
    private function serverError(context:Object, errorInfo:Object):Boolean
    {
        context.logWarn("serverError: state=" + errorInfo.state + ", type=" + errorInfo.type);        
        switch (errorInfo.state)
        {
        case "connect":
            if ("app" == errorInfo.type)
            {
                // fatal connection error - probably invalid user ID/password                
                context.fShowDialogCallback("Connection error:\n\n" + errorInfo.msg +
                    "\n\nPlease check your login credentials using the configuration widget at chumby.com.\n");

                context.fSelectDesc.text = "Connection error.";
                context.fSelectOwner.text = "";                    
                break;
            }
            
            context.logWarn("serverError: reconnecting due to connect network error");
            if (!context.fLoginTimerID)
                context.fLoginTimerID = setInterval(context, "doLogin", context.kNetActionDelay);        
            return false;
            
        case "playlist":
            if ("app" == errorInfo.type)
            {
                context.logWarn("serverError: reconnecting due to playlist app error: " + errorInfo.msg);                
                if (!context.fLoginTimerID)
                    context.fLoginTimerID = setInterval(context, "doLogin", context.kNetActionDelay);        
            }
            else
            {            
                if (++context.fPlaylistErrorCount >= 5)
                {
                    // maybe the network is down?
                    context.fShowDialogCallback("Connection error:\n\n" +
                        "\n\nPlease check the network connection then reload the channel.\n");
                    break;
                }

                context.logWarn("serverError: retrying playlist");
                if (context.fPlaylistTimerID)
                    clearInterval(context.fPlaylistTimerID);
                
                context.fPlaylistTimerID = setInterval(context, "retryPlaylist", context.kNetActionDelay);        
            }
            return false;
        }
        
        return true;    // continue with next request
    }

    /**
        \brief Initialize the Sound object and establish event callbacks.        
      */
    private function initSound():Void
    {
		fSound = new Sound(_root); 
		
        var self:Object = this;
        
        // Sound callbacks
        fSound.onLoad = function(success)
        { 
            if (!self.fSoundLoadCount || (--self.fSoundLoadCount))
            {
                // it's possible to call loadSound a second time before the first
                // one has reported back onLoad - this will cause the first one
                // to report an error - so we need to discard that here:
                if (!success)
                    self.logWarn("Sound.onLoad: ignoring stale sound error onLoad event: " + self.fSoundLoadCount);
                else
                {
                    // maybe this means that we have overlapping sounds now - so try to stop the first one:
                    self.logWarn("Sound.onLoad: ignoring stale sound ok onLoad event: " + self.fSoundLoadCount);
                    if (!self.fIsPlaying)
                    {
                        self.logWarn("Sound.onLoad: not supposed to be playing");
                        stopAllSounds();
                        self.cancelSoundTimer();
                    }
                }
                return;
            }
                    
            if (success)
            {
                self.log("Sound.onLoad: success");
                // note: do not cancel the sound timer because sometimes
                // we get this event even though no bytes have been downloaded!
            }
            else
            {
                self.logWarn("Sound.onLoad: failed to load - moving to next traq");
                self.cancelSoundTimer();
                self.fSelectTraq = null;
                self.fIsPlaying = false;
                self.nextTraq(true);
            }
        } 
        
        fSound.onSoundComplete = function()
        {
            self.log("Sound.onSoundComplete");

            self.cancelSoundTimer();            
            self.fSelectTraq = null;
            self.fIsPlaying = false;
            self.fServer.reportHeard(self.fTraqInfo[self.fTraqIndex]);
            self.nextTraq(true);
        }		
    }
    
    /**
        \brief Login to the server.  Again.
      */
    private function doLogin():Void
    {
        clearInterval(fLoginTimerID);
        fLoginTimerID = 0;
        
        fServer.login(kVersion, fUser, fPwd, fAnonBeLike);    
    }
    
    /**
        \brief Constructor
        \param mainImage MovieClip reference to the current track's image
        \param channelImage MovieClip reference to the current channel's image
        \param selectTitle TextField reference to the current track's title
        \param selectDesc TextField reference to the current track's description
        \param selectOwner TextField reference to the current track's owner
        \param selectChannel TextField reference to the current track's channel
        \param selectChannelDesc TextField reference to the current track's channel's description
        \param loveOpinion Button reference for the user's "love" opinion
        \param hateOpinion Button reference for the user's "hate" opinion
        \param prevBtn Button reference for the previous track action
        \param nextBtn Button reference for the next track action
        \param statusMsg TextField reference for the status message
        \param nowPlaying callback with the following signature: function():Void
        \param showDialog callback with the following signature: function(msg:String):Void
      */
    public function Controller(mainImage:Object, channelImage:Object, selectTitle:TextField, selectDesc:TextField, selectOwner:TextField, selectChannel:TextField, selectChannelDesc:TextField, prevBtn:Object, nextBtn:Object, loveOpinion:Object, hateOpinion:Object, statusMsg:TextField, nowPlaying:Function, showDialog:Function)
    {
        fRequestPlay = fIsPlaying = false;
        fSoundUseCount = 0;
        fSoundLoadCount = 0;
        
        fMainImage = mainImage;
        fChannelImage = channelImage;
        fSelectImageName = null;
        fSelectChannelImageName = null;
        fSelectTraq = null;
        fSelectTitle = selectTitle;
        fSelectDesc = selectDesc;
        fSelectOwner = selectOwner;
        fSelectChannel = selectChannel;
        fSelectChannelDesc = selectChannelDesc;
        fPrevBtn = prevBtn;
        fNextBtn = nextBtn;
        fLoveOpinion = loveOpinion;
        fHateOpinion = hateOpinion;
        fStatusMsg = statusMsg;
        
        fNowPlayingCallback = nowPlaying;
        fShowDialogCallback = showDialog;
    
		// main image viewer
		fMainImageLoader = new MovieClipLoader();
		fMainImageListener = new Object();

        fMainImageListener.onLoadInit = function(target:MovieClip) 
        {
            // scale down image to fit in 100x100
            target._xscale = 100*100/target._width;
            target._yscale = 100*100/target._height;
            
            target._width = target._height = 100;
            target._rotation = 0;
        }
        
        fMainImageLoader.addListener(fMainImageListener);

		// channel image viewer
		fChannelImageLoader = new MovieClipLoader();
		fChannelImageListener = new Object();

        fChannelImageListener.onLoadInit = function(target:MovieClip) 
        {
            // scale down image to fit in 50x50
            target._xscale = 50*100/target._width;
            target._yscale = 50*100/target._height;
            
            target._width = target._height = 50;
            target._rotation = 0;
        }
        
        fChannelImageLoader.addListener(fChannelImageListener);
        
        initSound();
        
        // no traqs yet
        fTraqIndex = 0;
        fTraqInfo = [];
    }
        
	/**
	    \brief Connect to the server and establish the listening session.
        \param user username
        \param pwd user's password
        \param anonBeLike true if a new anonymous user should be created who is "like" username
	 */
    public function connect(user:String, pwd:String, anonBeLike:Boolean):Void
    {
        fUser = user;
        fPwd = pwd;
        fAnonBeLike = anonBeLike;
        
        fSelectDesc.text = "Connecting to server";
        fSelectOwner.text = "please wait...";
    
        fServer = new Server(this, traqsAvailable, serverError);
        fServer.login(kVersion, user, pwd, anonBeLike);
    }
    
	/**
	    \brief Start playing the user's stream.
        \return true if playback has started, false otherwise
	 */
    public function play():Boolean
    {
        if (!fTraqInfo.length)
            return false;
        
        fRequestPlay = true;
        loadTraq(fTraqInfo[fTraqIndex]);

        return true;
    }
    
	/**
	    \brief Stop playing the user's stream.
        \return true if playback has stopped, false otherwise
	 */
    public function pause():Boolean
    {
        if (!fTraqInfo.length)
            return false;

        fRequestPlay = false;

        if (fStartPlayTimer)
        {
            logWarn("pause: cancelling start play timer");
            clearInterval(fStartPlayTimer);
            fStartPlayTimer = 0;
        }            
        else
        {
            stopAllSounds();
            cancelSoundTimer();
        }

        fIsPlaying = false;        
        return true;
    }
    
	/**
	    \brief Move to the previous track
        \return true if successful, false if this is the first track or no tracks are available
	 */
    public function prevTraq():Boolean
    {
        if (!fTraqInfo.length)
            return false;

        if (fTraqIndex == 0)
        {
            log("prevTraq: no more traqs");
            return false;
        }
            
        if (fStartPlayTimer)
        {
            logWarn("prevTraq: start play timer active - ignoring");
            return false;
        }
            
        if (fIsPlaying)
        {
            fSound.stop();
            cancelSoundTimer();            
        }
        
        loadTraq(fTraqInfo[--fTraqIndex]);        
        fPrevBtn._visible = (fTraqIndex > 0);   
        fNextBtn._visible = true;
        return true;
    }
    
	/**
	    \brief Move to the next track.  If we're running low on tracks, request more.
	    \param systemIntiated false if user-initated, true otherwise
        \return true if successful, false if this is the last track or no tracks are available
	 */
    public function nextTraq(systemIntiated:Boolean):Boolean
    {
        if (!fTraqInfo.length)
            return false;
          
        // if this was a user-initiated action, it is treated as "skipped" if
        // "enough" of the traq has played (50% of length or >treatAsSkipSec)
        if (!systemIntiated && ((fSound.position > fServer.getTreatAsSkipMSec()) ||
            (fSound.position > (fTraqInfo[fTraqIndex].length/2))))
        {            
            if (fTraqInfo[fTraqIndex].groupID)
                log("nextTraq: reporting traq " + fTraqInfo[fTraqIndex].traqID + " as skipped (group " + fTraqInfo[fTraqIndex].groupID + ")");
            else                
                log("nextTraq: reporting traq as skipped: " + fTraqInfo[fTraqIndex].traqID);

            fServer.reportSkipped(fTraqInfo, fTraqIndex);
            
            // in case some future traqs have been removed
            updateStatusMsg();
        }
        
        if (fTraqIndex >= (fTraqInfo.length - 2))
            retryPlaylist();

        if (fTraqIndex == (fTraqInfo.length - 1))
        {
            log("nextTraq: no more traqs, index=" + fTraqIndex);
            return false;
        }
        
        if (fStartPlayTimer)
        {
            logWarn("nextTraq: start play timer active - ignoring");
            return false;
        }        
                    
        if (fIsPlaying && !systemIntiated)        
        {
            fSound.stop();
            cancelSoundTimer();
        }    
                    
        loadTraq(fTraqInfo[++fTraqIndex]);
        fPrevBtn._visible = true;
        fNextBtn._visible = (fTraqIndex < (fTraqInfo.length - 1));        
        return true;
    }        

	/**
	    \brief Send up the user's opinion for the current track.
	    \param opinion "love", "hate", or "neutral"
        \return true if successful, false if there is no current track
	 */
    public function opinion(o:String):Boolean
    {
        if (!fTraqInfo.length)
            return false;
        
        var traq:Object = fTraqInfo[fTraqIndex];

        // update our knowledge of the traq's opinion
        switch (o)
        {
            case "love":
                traq["opinion"] = 1;
                break;
                
            case "hate":
                traq["opinion"] = 0;
                break;
                
            case "neutral":
                delete traq.opinion;
        }
        
        fServer.reportOpinion(traq, o);
        return true;
    }
    
    /**
        \return current traq/position string to report to the server
                or "" if there is no current traq or this information
                hasn't changed since the last request.
     */
    public function getCurrentTraq():String
    {
        if (!fSelectTraq || (fSound.position === undefined))
            return '';
            
        // note: if fSoundLoadCount != 0, we haven't gotten the onLoad event so .position 
        //       is not reliable, but it's better to report '0' than nothing
        var currentTraqPos = fSelectTraq + ',' + (fSoundLoadCount ? '0' : fSound.position);
        if (currentTraqPos === fLastReportedCurrentTraq)
            return '';
            
        return (fLastReportedCurrentTraq = currentTraqPos);
    }    
    
    public function getVersion():String
    {
        return kVersion;
    }
}
